Skip to content

feat(aws): WIF-only backplanes with a shared OIDC provider, enforced by the scorecard - #293

Merged
JohannesRudolph merged 7 commits into
mainfrom
claude/aws-wif-external-oidc-provider
Aug 25, 2026
Merged

feat(aws): WIF-only backplanes with a shared OIDC provider, enforced by the scorecard#293
JohannesRudolph merged 7 commits into
mainfrom
claude/aws-wif-external-oidc-provider

Conversation

@JohannesRudolph

Copy link
Copy Markdown
Member

Replaces #286 and #287, squashed onto current main with the intermediate states dropped — the create_oidc_provider toggle that those two added and then removed never appears.

Three AWS backplanes (s3_bucket, route53-dns-record, route53-dns-alias-record) carried a workload_identity_federation variable defaulting to null, with count = var.workload_identity_federation == null ? 1 : 0 selecting an IAM user and an aws_iam_access_key on the null branch. .agents/references/aws-backplane.md already forbade that shape and nothing enforced it. They also each created their own OIDC provider, which AWS only allows one of per issuer URL per account.

This does both: drops the dead credential path, and moves the OIDC provider out to a module of its own — then adds the scorecard category that keeps it that way.

Commits

feat(aws/oidc-provider) the shared provider, zero inputs, applied once per AWS account
docs(aws) Pattern A now requires an external provider; adds "The shared OIDC provider"
refactor(aws/route53-dns-record) WIF only, external provider
refactor(aws/route53-dns-alias-record) same
refactor(aws/s3_bucket) same, plus the two conventions it never had
feat(scorecard) the AWS Backplane category, ten pattern-scoped checks
test(aws/s3_bucket) the first e2e test for any AWS hub module

Why the provider is externalized

AWS registers one OIDC provider per issuer URL per account. The meshStack runner has one issuer, so an account has room for exactly one no matter how many backplanes federate through it. A backplane that creates its own is claiming shared infrastructure: the second backplane in the account fails with EntityAlreadyExists, and destroying whichever owns it breaks every other one.

This is where AWS genuinely differs from the others, and why the repetition is not the same repetition: an Azure federated identity credential is a child of its UAMI and a GCP workload identity pool is a named per-project resource, so each module can own its own and none can collide. Only AWS has an account-level singleton keyed by the issuer URL.

Backplanes therefore take oidc_provider_arn and create nothing — losing the provider resource, the data lookup that mirrored it and the try() local that picked between them. The description is a fixed two-line notice, copied verbatim into the integrations' aws_oidc_provider_arn:

ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.
See .agents/references/aws-backplane.md#the-shared-oidc-provider

aws_oidc_provider_notice lints it, because that notice is the only signpost a first-time platform engineer gets: AWS has no plural OIDC-provider data source, so a missing provider cannot be turned into a friendly precondition, and terraform-docs runs only on backplane/ and buildingblock/, so a comment in meshstack_integration.tf is rendered nowhere.

Migration is a forget plus an import

No destroy anywhere. Each backplane ships both:

moved {   # the federated [0] addresses exist in production state
  from = aws_iam_role.assume_federated_role[0]
  to   = aws_iam_role.assume_federated_role
}

removed {  # the provider is shared now — forget it, do not delete it
  from = aws_iam_openid_connect_provider.buildingblock_oidc_provider
  lifecycle { destroy = false }
}

For internal-cloudfoundation's meshcloud-prod that is one forget and one import: dns owns the provider today and dns-alias already passed create_oidc_provider = false. A deployment still on the access-key path must start passing workload_identity_federation; its next apply destroys the IAM user and revokes the key, which is the intended migration.

Verification

The e2e test passes against a live AWS account. Run 32849844853hub:aws/s3_bucket, 1 passed, 0 failed. The AWS content of this branch is byte-identical to the commit that run tested. It proves what static analysis cannot: no static credential exists anywhere, the backplane created no OIDC provider and federated through the account's shared one, and the claims are right — a wrong audience fails the STS exchange and a wrong subject the trust policy, yet the bucket exists and the assertions check its ARN.

Also green: tofu validate on all three backplanes, on each integration root wired against its local backplane, and on the e2e module with its whole chain resolved; the full pre-commit suite, which is what CI runs; and every AWS backplane at 🟢 100% on the new category. Full scorecard diffed against main — no pre-existing category verdict moved, 53 modules both sides.

Each of the ten checks was exercised against deliberately non-compliant fixtures as well as compliant ones — one pair per pattern, plus a backplane matching neither — so none is a vacuous pass. The fixtures are deleted.

Not included

aws/s3_bucket sits at 🟡 75% Testing because main's new no_buildingblock_tftest check flags its buildingblock/terragrunt.tftest.hcl. Folding that into the e2e suite is not quite possible yet — it asserts on tags, which the BBD exposes no input for — so it stays in main's own migration backlog rather than being deleted with its coverage unreplaced.

🤖 Generated with Claude Code

JohannesRudolph and others added 7 commits August 25, 2026 14:59
AWS registers one OIDC provider per issuer URL per account, so the meshStack runner's issuer can be
registered only once in an account regardless of how many building block backplanes federate through
it. It is therefore platform infrastructure, not something a building block owns.

This module is that provider on its own, applied once per AWS account that hosts backplanes. It
takes no inputs — issuer, audience and thumbprint come from `data.meshstack_integrations` — so a
platform team supplies only the two provider configurations and passes the `arn` output to every
backplane in the account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pattern A had every backplane create its own `aws_iam_openid_connect_provider` with a
`create_oidc_provider` toggle to opt out. That makes the first backplane deployed into an account
the de-facto owner of shared infrastructure: the second one fails with EntityAlreadyExists unless
somebody remembers the toggle, and destroying the owner breaks every other backplane federating
through the same issuer.

Pattern A now takes the provider's ARN as a required input, and 'The shared OIDC provider' is the
section that says why, which module owns it, and how to migrate an account whose provider still sits
in a backplane's state (a `removed` block with `destroy = false`, then an import — no destroy).

It also records why this is not the same repetition Azure and GCP carry: a federated identity
credential is a child of its UAMI and a workload identity pool is a named per-project resource, so
those modules can each own theirs and none can collide. Only AWS has an account-level singleton
keyed by the issuer URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…vider

The backplane offered two credential paths: workload identity federation, or an IAM user with an
`aws_iam_access_key` when `workload_identity_federation` was left null. Nothing was ever on the null
branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and
internal-cloudfoundation's production deployment is federated — so the IAM user, its policy
attachment and its access key were dead code behind a `count` on six resources, a policy name that
branched on the same condition, and a `credentials` output that published the literal string
"N/A; workload identity federation in use".

`workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created
here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS
account. That removes the provider resource, the `data` lookup that mirrored it, the
`create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is
unchanged — it still scopes `:sub` to this building block definition's subjects.

The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed.

Two migration blocks make this a no-op for a deployment already on the federated path:

- `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed
  `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it
  the next apply would destroy and recreate a live IAM role.
- `removed` with `destroy = false` drops the OIDC provider from this backplane's state without
  deleting it out from under every other backplane in the account. Import it into the root that
  applies `modules/aws/oidc-provider` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…DC provider

The backplane offered two credential paths: workload identity federation, or an IAM user with an
`aws_iam_access_key` when `workload_identity_federation` was left null. Nothing was ever on the null
branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and
internal-cloudfoundation's production deployment is federated — so the IAM user, its policy
attachment and its access key were dead code behind a `count` on six resources, a policy name that
branched on the same condition, and a `credentials` output that published the literal string
"N/A; workload identity federation in use".

`workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created
here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS
account. That removes the provider resource, the `data` lookup that mirrored it, the
`create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is
unchanged — it still scopes `:sub` to this building block definition's subjects.

The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed.

Two migration blocks make this a no-op for a deployment already on the federated path:

- `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed
  `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it
  the next apply would destroy and recreate a live IAM role.
- `removed` with `destroy = false` drops the OIDC provider from this backplane's state without
  deleting it out from under every other backplane in the account. Import it into the root that
  applies `modules/aws/oidc-provider` instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same conversion as the two route53 backplanes — the IAM user, its policy attachment and its
`aws_iam_access_key` were dead code on a null branch the integration never takes,
`workload_identity_federation` becomes `nullable = false`, and `oidc_provider_arn` replaces a
self-created OIDC provider — plus two conventions this module never had:

- **No `create_oidc_provider` equivalent existed**, so the backplane always created its own provider
  and a second backplane in the same AWS account failed its apply with EntityAlreadyExists. It now
  takes the ARN like the others.
- **The role ARN output was named `workload_identity_federation_role_arn`**; the convention is
  `workload_identity_federation_role`. Renamed, with the BBD's `AWS_ROLE_ARN` input updated.

`moved` blocks carry the role and its policy attachment across the removed `count`, and a `removed`
block with `destroy = false` drops the OIDC provider from state without deleting it. The policy keeps
its federated-path name so no live IAM policy is renamed.

Not changed: this integration still takes a hand-supplied `variable "workload_identity"` instead of
reading `data.meshstack_integrations` the way the route53 modules do. That is a separate
input-contract change for its consumers and no scorecard check covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Azure, GCP and STACKIT backplanes have their identity conventions enforced by the scorecard. AWS had
none, even though `.agents/references/aws-backplane.md` documents them — which is how three AWS
backplanes could carry a nullable-workload-identity-federation-with-an-access-key-fallback and score
100%.

Adds an `AWS Backplane` category: ten checks over what that reference documents.

**AWS documents two legitimate patterns, so the checks are pattern-scoped.** Pattern A (external
OIDC provider + IAM role) is for a building block acting in a single account; Pattern B (IAM user +
CloudFormation StackSet) is for org-wide building blocks that must reach every account in an OU, and
it mints an `aws_iam_access_key` on purpose. A blanket "no access key" check would therefore be
wrong. Each check declares its pattern and reports `➖` for the other, the way `terraform_version`
already reports `➖` for `manual` implementations. A backplane carrying federation machinery
classifies as Pattern A *even when it also mints a key*: that hybrid is exactly the fallback the
reference's first "What to Avoid" bullet forbids, and `aws_wif_no_access_key` reports it.

| Check | Pattern | Enforces |
|---|---|---|
| `aws_wif_external_oidc_provider` | A | creates no OIDC provider; takes a required non-nullable `oidc_provider_arn` |
| `aws_oidc_provider_notice` | A | the fixed two-line notice on `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` |
| `aws_wif_no_access_key` | A | no `aws_iam_access_key` — the Azure `no_app_password` analogue |
| `aws_wif_nonnullable` | A | `nullable = false`, no `default = null` fallback |
| `aws_wif_subject_condition` | A | the `:sub` condition uses `var.workload_identity_federation.subjects` |
| `aws_wif_role_output` | A | `workload_identity_federation_role`, ARN constructed not read off the resource |
| `aws_wif_integration_env` | A | integration wires `AWS_ROLE_ARN` + `AWS_WEB_IDENTITY_TOKEN_FILE` |
| `aws_cross_account_provider_aliases` | B | `configuration_aliases` declares `aws.management` and `aws.backplane` |
| `aws_stackset_auto_deployment` | B | `SERVICE_MANAGED`, auto-deploying, `retain_stacks_on_account_removal = false`, `administration_role_arn` ignored |
| `aws_cross_account_outputs` | B | `aws_access_key_id`, sensitive `aws_secret_access_key`, `role_name` |

`aws_oidc_provider_notice` exists because that notice is the only signpost a first-time platform
engineer gets. AWS has no plural OIDC-provider data source, so a missing provider cannot be turned
into a friendly precondition; and terraform-docs runs only on `backplane/` and `buildingblock/`, so a
comment in `meshstack_integration.tf` is rendered nowhere. Linting the copy-pasted notice is what
keeps the pointer to the instructions alive.

Each check was exercised against deliberately non-compliant fixtures as well as compliant ones — one
pair per pattern, plus a backplane matching neither — so none is a vacuous pass, and every branch of
every detail message was driven individually. The fixtures are deleted.

Two judgement calls worth recording:

- `role_name` is required only of StackSet-based Pattern B backplanes. The reference ties that output
  to "the IAM role deployed by StackSet to each target account"; `aws/opt-in-region` reaches a single
  management account through a role it creates itself and has no such name to publish. Requiring it
  there would be inventing a convention rather than enforcing one.
- The category does not apply to a `backplane/` holding no `.tf` files of its own.
  `aws/agentic-coding-sandbox` is a composition whose backplane README says it "does not need any
  dedicated backplane", keeping only a `landingzone/` submodule there — it declares no automation
  principal to judge.

`aws-backplane.md` joins `REF_FILES` and carries the `scorecard-checks` markers, so `--fix` links the
section explaining each fix. Its two `### Implementation Pattern` headings became `(WIF)` and
`(Cross-Account)` — they collided on one anchor.

One supporting change: a category whose checks are all pattern-scoped can mark every one of them not
applicable, leaving no score to render. The per-category table now prints `—` for that instead of
`null%`. No module hits it today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First e2e test for any AWS hub module. Build-from-source mode provisions an ephemeral backplane in
the smoke-test AWS account, builds the BBD from the branch under test, orders a workspace-level
building block, and asserts on its outputs — the bucket ARN in particular, since that is what proves
the bucket was created by the federated backplane role rather than the name being echoed back.

Modelled on `gcp/storage-bucket/e2e`, with two AWS-specific details:

- `aws_oidc_provider_arn` comes from `fixtures.aws.oidc_provider_arn`. The harness owns the account's
  OIDC provider, so the backplane is handed the ARN rather than creating one per run — which also
  keeps parallel AWS e2e cases from contending over an account-level singleton.
- The integration takes `workload_identity` as a hand-supplied object rather than reading
  `data.meshstack_integrations` itself, so the e2e module reads the data source and derives the
  subject namespace prefix from the replicator's own subject.

`provider "aws"` pins `allowed_account_ids` to the fixture account, so a wrong local session errors
instead of creating IAM roles in someone else's account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Scorecard Check

Scorecard run on commit ee86189b55f2143d0c31ba94b5c04c6570d08505 relative to origin/main

Warning: module "aws/oidc-provider" not found — skipping.

📊 meshstack-hub Module Scorecard

Generated: 2026-08-25 | Modules scanned: 3 | Categories: 7

📋 Per-Module Category Summary

Score per category per building block. n/a = category does not apply to this module.

Module Overall Core Structure Integration AWS Backplane Azure Backplane GCP Backplane STACKIT Backplane Testing
aws/route53-dns-alias-record 🟢 94% 🟢 100% 🟢 100% 🟢 100% n/a n/a n/a 🟡 50%
aws/route53-dns-record 🟢 94% 🟢 100% 🟢 100% 🟢 100% n/a n/a n/a 🟡 50%
aws/s3_bucket 🟢 97% 🟢 100% 🟢 100% 🟢 100% n/a n/a n/a 🟡 75%

⚠️ 3 modules have failing checks — failing categories are expanded below.

Core Structure — ✅ all passing

Basic module file structure and documentation — applies to 3 modules

Module Score 📦 🔗 📋 📝 🖼️ 📌 🔒
aws/route53-dns-alias-record 🟢 100%
aws/route53-dns-record 🟢 100%
aws/s3_bucket 🟢 100%

Core Structure — Summary

Emoji Criterion Coverage Status
📦 buildingblock/ directory exists 3/3 🟢 100%
🔗 meshstack_integration.tf present 3/3 🟢 100%
📋 buildingblock/APP_TEAM_README.md present (no-integration fallback) n/a
📝 buildingblock/README.md with YAML front-matter 3/3 🟢 100%
🖼️ buildingblock/logo.png included 3/3 🟢 100%
📌 buildingblock/versions.tf present 3/3 🟢 100%
🔒 Provider versions use minimum constraint (>=) 3/3 🟢 100%
Integration — ✅ all passing

meshstack_integration.tf conventions — applies to 3 modules

Module Score 🏷️ 🏢 📤 🔌 📎 🔀 🌱 📋 🏷️ 🧱 📖 📝 📊 🚫 🔄
aws/route53-dns-alias-record 🟢 100%
aws/route53-dns-record 🟢 100%
aws/s3_bucket 🟢 100%

Integration — Summary

Emoji Criterion Coverage Status
🏷️ variable "hub" in integration 3/3 🟢 100%
🏢 variable "meshstack" in integration 3/3 🟢 100%
📤 building_block_definition output exposed 3/3 🟢 100%
🔌 meshcloud/meshstack in required_providers 3/3 🟢 100%
📎 backplane source uses var.hub.git_ref 3/3 🟢 100%
🔀 ref_name uses var.hub.git_ref 3/3 🟢 100%
🌱 BBD terraform_version >= 1.12.0 3/3 🟢 100%
📋 version_spec.draft uses var.hub.bbd_draft 3/3 🟢 100%
🏷️ BBD metadata.tags forwards var.meshstack.tags 3/3 🟢 100%
🧱 BBD input argument vars with optional() have explicit defaults 3/3 🟢 100%
📖 BBD readme field present 3/3 🟢 100%
📝 BBD readme starts with plain-text description (no heading) 3/3 🟢 100%
📊 BBD readme has shared responsibility table (✅/❌) 3/3 🟢 100%
🚫 No documentation_md output in backplane 3/3 🟢 100%
🔄 meshstack_platform has lifecycle ignore_changes = [availability] n/a
AWS Backplane — ✅ all passing

AWS automation principal conventions (WIF or cross-account StackSet) — applies to 3 modules

Module Score 🔐 📌 🚫 🛂 📤 🌐 🧭 📚 🔑
aws/route53-dns-alias-record 🟢 100%
aws/route53-dns-record 🟢 100%
aws/s3_bucket 🟢 100%

AWS Backplane — Summary

Emoji Criterion Coverage Status
🔐 Takes oidc_provider_arn instead of creating a provider 3/3 🟢 100%
📌 oidc_provider_arn carries the shared-provider notice 3/3 🟢 100%
🚫 No aws_iam_access_key on the federation path 3/3 🟢 100%
workload_identity_federation is non-nullable 3/3 🟢 100%
🛂 Trust policy scopes :sub to the BBD's WIF subjects 3/3 🟢 100%
📤 Outputs workload_identity_federation_role as a constructed ARN 3/3 🟢 100%
🌐 Integration wires AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE 3/3 🟢 100%
🧭 Declares aws.management and aws.backplane aliases n/a
📚 StackSet is SERVICE_MANAGED, auto-deploying, retaining nothing n/a
🔑 Outputs the access key, a sensitive secret, and the target role name n/a
Azure Backplane — not applicable

Azure UAMI-based automation principal conventions — applies to 0 modules

No applicable modules.

GCP Backplane — not applicable

GCP workload-identity-federation automation principal conventions — applies to 0 modules

No applicable modules.

STACKIT Backplane — not applicable

STACKIT WIF-based automation principal conventions — applies to 0 modules

No applicable modules.

Testing — some checks failing

End-to-end test coverage — applies to 3 modules

Module Score ⚙️ 🧪 🚫
aws/route53-dns-alias-record 🟡 50%
aws/route53-dns-record 🟡 50%
aws/s3_bucket 🟡 75%

Testing — Summary

Emoji Criterion Coverage Status
⚙️ backplane/ directory (optional tier) 3/3 🟢 100%
🧪 e2e/ test directory exists 1/3 🔴 33%
🚫 no .tftest.hcl outside e2e/ 2/3 🟡 67%
e2e/ contains .tftest.hcl files 1/3 🔴 33%

📈 Overall Summary

Overall Average Score: 95%

Score Distribution

  • 🟢 High maturity (≥80%): 3 modules
  • 🟡 Medium maturity (50–79%): 0 modules
  • 🔴 Low maturity (<50%): 0 modules

@aws-amplify-eu-central-1

Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-293.d1o16zfeoh2slu.amplifyapp.com

@JohannesRudolph JohannesRudolph left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@JohannesRudolph
JohannesRudolph marked this pull request as ready for review August 25, 2026 15:13
@JohannesRudolph
JohannesRudolph merged commit 9a21a03 into main Aug 25, 2026
3 checks passed
@JohannesRudolph
JohannesRudolph deleted the claude/aws-wif-external-oidc-provider branch August 25, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant